Перейти к основному содержимому

5.16. Типы данных

Разработчику Архитектору

Типы данных

Ассемблер — язык низкого уровня, максимально приближенный к машинному коду. Он предоставляет прямой доступ к ресурсам процессора и памяти компьютера. В отличие от языков высокого уровня, таких как Python, Java или C#, ассемблер не оперирует абстрактными типами данных вроде «строка», «целое число» или «булево значение». Вместо этого он работает с блоками памяти фиксированного размера, которые интерпретируются программистом в зависимости от контекста использования.


Отсутствие типов в классическом смысле

В ассемблере нет системы типов, как она реализована в современных языках программирования. Процессор не хранит информацию о том, что конкретный участок памяти содержит текст, число или адрес. Все данные представляются в виде последовательностей битов, организованных в байты. Программист сам определяет, как интерпретировать эти байты: как символ, как беззнаковое целое, как указатель на другую область памяти или как часть машинной инструкции.

Эта особенность делает ассемблер чрезвычайно гибким, но одновременно требует от разработчика глубокого понимания структуры данных и поведения аппаратуры. Ошибка в интерпретации может привести к некорректной работе программы, повреждению памяти или сбоям системы.


Блоки памяти как основа представления данных

Все данные в ассемблере рассматриваются как непрерывные блоки памяти, состоящие из одного или нескольких байтов. Наиболее распространённые единицы измерения памяти в контексте ассемблера:

  • Байт (byte) — 8 бит, минимальная адресуемая единица памяти на большинстве архитектур.
  • Слово (word) — 16 бит (2 байта). Этот термин исторически связан с 16-битными процессорами, такими как Intel 8086.
  • Двойное слово (double word, dword) — 32 бита (4 байта).
  • Четверное слово (quad word, qword) — 64 бита (8 байт).

Размеры этих блоков могут варьироваться в зависимости от архитектуры процессора, но в x86 и x86-64 они закреплены именно так. Эти блоки используются для хранения значений, передачи данных между регистрами и памятью, а также для организации структур более высокого уровня.

Каждый блок памяти — это просто набор битов. Сам по себе он не содержит метаинформации о своём назначении. Только контекст выполнения программы определяет, как этот блок следует читать и обрабатывать.


Интерпретация данных зависит от контекста

Одна и та же последовательность байтов может быть интерпретирована множеством способов. Например, байт со значением 0x41 в шестнадцатеричной системе:

  • При выводе на экран как символ в кодировке ASCII будет отображён как латинская буква A.
  • При арифметической операции будет воспринят как десятичное число 65.
  • При использовании в качестве части адреса будет рассматриваться как младший байт указателя.

Ассемблер предоставляет инструкции, которые работают с данными определённого размера. Например, инструкция MOV AL, 65 загружает значение 65 в 8-битный регистр AL. Инструкция MOV AX, 65 загружает то же число в 16-битный регистр AX. Хотя числовое значение одинаково, размер используемого регистра и объём затронутой памяти различаются.

Процессор не проверяет, соответствует ли значение в регистре ожидаемому типу. Он просто выполняет операцию над битами. Это означает, что программист полностью отвечает за корректность интерпретации данных.


Регистры и их роль в работе с данными

Регистры процессора — это сверхбыстрые ячейки памяти, встроенные непосредственно в CPU. Они используются для временного хранения данных, адресов и промежуточных результатов вычислений. В ассемблере регистры часто имеют фиксированный размер и предназначение:

  • 8-битные регистры: AL, BL, CL, DL — работают с отдельными байтами.
  • 16-битные регистры: AX, BX, CX, DX — работают со словами.
  • 32-битные регистры: EAX, EBX, ECX, EDX — работают с двойными словами.
  • 64-битные регистры: RAX, RBX, RCX, RDX — работают с четверными словами.

Некоторые регистры имеют специальное назначение. Например, регистр SP (Stack Pointer) указывает на вершину стека, а IP (Instruction Pointer) содержит адрес следующей выполняемой инструкции. Однако даже специализированные регистры хранят просто числа — их смысл определяется архитектурой и текущим контекстом выполнения.


Примеры интерпретации одного и того же блока памяти

Рассмотрим 4-байтовый блок памяти со значениями: 48 65 6C 6C (в шестнадцатеричной записи).

  • Если прочитать его как последовательность ASCII-символов, получится строка "Hell".
  • Если интерпретировать как 32-битное целое число в формате little-endian (младший байт первый), значение будет равно 0x6C6C6548, что в десятичной системе составляет 1 819 306 312.
  • Если использовать эти байты как часть машинного кода, они могут представлять собой одну или несколько инструкций процессора, в зависимости от архитектуры.

Таким образом, один и тот же фрагмент памяти может выполнять разные функции в разных частях программы. Ассемблер не накладывает ограничений на использование памяти — он лишь предоставляет средства для её чтения, записи и обработки.


Указатели и адреса как данные

В ассемблере адреса памяти тоже являются числами. Указатель — это просто значение, которое интерпретируется как адрес другой ячейки памяти. Размер указателя зависит от разрядности архитектуры:

  • В 32-битных системах указатель занимает 4 байта.
  • В 64-битных системах — 8 байт.

Инструкции вроде MOV EAX, [EBX] означают: «загрузить в регистр EAX значение, находящееся по адресу, хранящемуся в регистре EBX». Здесь EBX содержит адрес, а не само значение. Такой подход позволяет реализовывать сложные структуры данных — массивы, списки, деревья — даже без встроенной поддержки типов.


Символы и строки

Строки в ассемблере — это последовательности байтов, каждый из которых представляет символ в определённой кодировке (чаще всего ASCII или UTF-8). Нет встроенного типа «строка». Программист сам определяет:

  • Где начинается строка (адрес первого байта).
  • Как она завершается (например, нулевым байтом \0 в стиле C).
  • Как её обрабатывать (побайтово, как массив, с использованием специальных строковых инструкций вроде LODSB, STOSB и т.д.).

Это даёт полный контроль над обработкой текста, но требует ручного управления длиной, кодировкой и границами.


Числовые данные: знаковые и беззнаковые

Хотя ассемблер не различает типы, он предоставляет инструкции, учитывающие знак числа. Например:

  • Инструкции ADD, SUB, MUL работают одинаково с битами, независимо от знака.
  • Но инструкции сравнения (CMP) и условные переходы (JG, JL, JA, JB) различают знаковое и беззнаковое сравнение.

Переход JG (Jump if Greater) интерпретирует операнды как знаковые числа, тогда как JA (Jump if Above) — как беззнаковые. Это означает, что программист должен осознанно выбирать инструкции в зависимости от предполагаемой интерпретации данных.

Флаги процессора (например, флаг переноса CF и флаг знака SF) помогают отслеживать результаты операций и принимать решения на основе правильной семантики.


Организация памяти и выравнивание

Процессор обращается к памяти по адресам, каждый из которых соответствует одному байту. Однако эффективность доступа зависит от того, как данные расположены в памяти. Современные процессоры оптимизированы для чтения и записи данных, выровненных по границам, кратным их размеру:

  • 16-битное слово эффективно читается, если его адрес кратен двум.
  • 32-битное двойное слово — если адрес кратен четырём.
  • 64-битное четверное слово — если адрес кратен восьми.

Выравнивание не является обязательным требованием на всех архитектурах, но его соблюдение ускоряет выполнение программы. Некоторые процессоры (например, ARM в определённых режимах) генерируют исключение при попытке не выровненного доступа к данным. В ассемблере программист сам отвечает за размещение данных и может явно контролировать выравнивание с помощью директив ассемблера, таких как .align или ORG.


Работа с массивами

Массив в ассемблере — это последовательность блоков памяти одинакового размера, расположенных подряд. Тип элементов массива определяется программистом. Например, массив байтов занимает по одному байту на элемент, массив слов — по два байта, и так далее.

Для доступа к элементу массива используется базовый адрес начала массива и смещение, вычисляемое как произведение индекса на размер элемента. Хотя в ассемблере нет оператора индексации вроде array[i], эту логику легко реализовать с помощью арифметики адресов:

; Предположим, массив слов начинается по метке 'my_array'
; Нужно получить третий элемент (индекс 2)
MOV BX, OFFSET my_array ; загрузка базового адреса
MOV SI, 2 ; индекс
SHL SI, 1 ; умножение на 2 (размер слова = 2 байта)
ADD BX, SI ; BX теперь указывает на третий элемент
MOV AX, [BX] ; загрузка значения в AX

Этот пример демонстрирует, что даже сложные структуры данных строятся из простых операций над адресами и блоками памяти.


Структуры и записи

Хотя ассемблер не предоставляет встроенной поддержки структур, такие конструкции можно моделировать вручную. Структура — это фиксированный набор полей, каждое из которых имеет известное смещение относительно начала структуры.

Например, структура, описывающая точку на плоскости с координатами X и Y (по 32 бита каждая), будет занимать 8 байт. Поле X находится по смещению 0, поле Y — по смещению 4. Для доступа к полям используются те же принципы, что и при работе с массивами — через базовый адрес и смещение.

Некоторые ассемблеры (например, MASM или NASM с макросами) позволяют определять шаблоны структур, чтобы упростить вычисление смещений и повысить читаемость кода. Однако на уровне машинного кода структура остаётся просто блоком памяти.


Упакованные данные и битовые поля

В ассемблере возможна работа с данными на уровне отдельных битов. Это особенно полезно в системном программировании, драйверах устройств или встраиваемых системах, где важна экономия памяти.

Битовые поля — это части одного байта или слова, выделенные под отдельные флаги или параметры. Например, один байт может содержать восемь логических значений (включено/выключено). Для извлечения или установки конкретного бита используются побитовые операции: AND, OR, XOR, NOT, а также сдвиги (SHL, SHR).

Пример: проверка, установлен ли третий бит в регистре AL:

TEST AL, 00000100b    ; маска для третьего бита
JNZ bit_is_set ; переход, если бит установлен

Такой подход позволяет компактно хранить информацию, но требует аккуратности при чтении и записи.


Представление чисел: порядок байтов

Один из ключевых аспектов работы с многобайтовыми данными — это порядок байтов (endianness). На архитектурах x86 и x86-64 используется little-endian: младший байт числа хранится по младшему адресу.

Например, 32-битное число 0x12345678 в памяти будет записано как:

Адрес:   N     N+1   N+2   N+3
Байты: 78 56 34 12

Это влияет на интерпретацию данных при прямом чтении памяти, при передаче данных между системами с разным порядком байтов, а также при работе с сетевыми протоколами (где обычно используется big-endian). В ассемблере программист должен учитывать этот порядок при работе с многобайтовыми значениями, особенно если данные поступают извне или предназначены для внешних систем.


Работа с текстом: кодировки и завершение строк

Текст в ассемблере — это массив байтов, каждый из которых соответствует коду символа. Наиболее распространённая кодировка — ASCII, где символы латиницы, цифр и знаков препинания занимают значения от 0 до 127. Расширенные кодировки (например, Windows-1251 или ISO-8859-1) используют старший бит байта для дополнительных символов.

В современных системах всё чаще применяется UTF-8 — переменная кодировка, совместимая с ASCII для базовых символов. В UTF-8 один символ может занимать от 1 до 4 байт. Ассемблер не содержит встроенной поддержки UTF-8, но программист может реализовать обработку таких строк, анализируя байты по правилам кодировки.

Строки в ассемблере обычно завершаются нулевым байтом (0x00), как в языке C. Это позволяет функциям определять конец строки без хранения её длины отдельно. Альтернативный подход — хранение длины в начале строки (как в Pascal), что ускоряет доступ к концу, но требует дополнительного байта.


Данные и исполняемый код: единая природа

В архитектуре фон Неймана, лежащей в основе большинства современных компьютеров, код и данные хранятся в одной и той же памяти. Это означает, что последовательность байтов, представляющая инструкцию процессора, может быть прочитана как данные, а блок данных — интерпретирован как код.

Такая двойственность лежит в основе многих техник, включая самомодифицирующийся код, JIT-компиляцию и эксплойты переполнения буфера. В ассемблере эта грань особенно тонка: инструкция JMP EAX передаёт управление по адресу, хранящемуся в регистре EAX, независимо от того, содержит ли этот адрес действительно исполняемый код.

Современные операционные системы используют механизмы защиты (например, NX-бит), чтобы запретить исполнение кода из областей памяти, помеченных как «данные». Но на уровне ассемблера эта защита — внешнее ограничение, а не свойство языка.